hermes dynamic threshold.spec
- canonical
- No value
- aliases
- No value
- tags
- hermes/spec
- description
- Hermes 컨텍스트 압축 트리거를 실측 바닥 기반으로 동적 산출하는 context engine 플러그인 스펙. hermes-agent git repo를 수정하지 않는다.
- links
- No value
- status
- No value
- project
- true
- area
- false
- resource
- false
- title
- hermes dynamic threshold.spec
- created
- 2026-08-10T15:23:55
- updated
- 2026-08-10T15:24:42
Hermes 동적 압축 threshold (context engine 플러그인)
문제정의
WHY: 압축 트리거(compression.threshold)는 컨텍스트 창의 고정 비율인데, 압축 후 착지점은 비율이 아니라 압축 불가능한 바닥(시스템 프롬프트 + 툴 스키마 + rolling summary + protect_first_n + protect_last_n)이 결정한다. 두 값이 서로 독립이라 사용자가 threshold를 낮게 잡으면 트리거 − 바닥 여유가 붕괴하고 압축이 매 1~2턴마다 재발한다.
실측 (세션 hermes-b4cea60738fe, deepseek/deepseek-v4-flash-0731, profile default):
| 항목 | 값 | 창 대비 |
|---|---|---|
context_length |
1,048,576 | 100% |
threshold_tokens (threshold 0.3) |
314,572 | 30% |
압축 후 실측 last_prompt_tokens |
256,857 | 24.5% |
| 실가용 여유 | ~100K | ~10%p |
target_ratio를 낮춰도 해결되지 않는다. tail_token_budget = threshold_tokens × target_ratio = 62,914로 이미 바닥보다 작아 binding constraint가 아니기 때문이다.
WHERE: agent/context_compressor.py의 threshold 산출 경로.
_compute_threshold_tokens()(agent/context_compressor.py:2473) —effective_window × threshold_percent_effective_threshold_percent()(:2456) — 창 512K 미만일 때만 75%로 raise-only 플로어should_compress_info()(:2921) —tokens < self.threshold_tokens단순 비교update_from_response()(:2741) — provider의 실제 prompt 토큰이 도착하는 유일한 지점
WHAT: 상류에 이미 동일 증상에 대한 방어가 있다. _SMALL_CTX_THRESHOLD_PERCENT = 0.75 (:821)의 주석은 정확히 이 문제를 서술한다 — "the incompressible floor eats most of the reclaimed headroom, so compaction re-fires every 1-2 turns". 다만 context_length < 512_000 조건이 붙어 있고, "512K 이상이면 기본값 50%로 여유가 충분하다"는 가정에 의존한다. 1M 창에 0.3을 명시 설정한 경우 그 가정이 깨진다.
이 스펙은 정적 플로어 대신 실측 바닥에 상대적인 threshold를 산출한다. 단, 구현 위치가 핵심 제약이다.
제약: hermes-agent git repo를 수정할 수 없다
~/.hermes/hermes-agent는 NousResearch/hermes-agent 클론이고 hermes update는 git stash → pull → git stash apply 흐름이다 (hermes_cli/update_cmd.py). 충돌 경로가 명시적으로 존재한다.
update_cmd.py:1307 "✗ Update pulled new code, but restoring local changes hit conflicts."
agent/context_compressor.py는 상류 hot file이므로 직접 패치 시 업데이트마다 충돌한다. 따라서 repo 밖 확장점을 사용한다.
agent/agent_init.py:2430 부근의 context engine 선택 순서:
- config
context.engine hermes-agent/plugins/context_engine/<name>/— repo 내부, 사용 금지- 일반 플러그인 시스템 (user-installed) ← 채택
- 내장
ContextCompressor
3번의 사용자 플러그인 디렉터리는 get_hermes_home()/plugins = ~/.hermes/plugins/ (hermes_cli/plugins.py:1526)로 repo 밖이다. ctx.register_context_engine() (hermes_cli/plugins.py:664)로 등록하고, ContextCompressor(ContextEngine) (agent/context_compressor.py:1577)를 상속해 최소 지점만 오버라이드한다.
예상 개발기간, 소요시간
IN-SCOPE
~/.hermes/plugins/adaptive-threshold/플러그인 신규 작성 (plugin.yaml,__init__.py)ContextCompressor서브클래스AdaptiveThresholdEnginename→"adaptive-threshold"update_from_response()오버라이드 — 압축 직후 실측치를 바닥으로 기록하고 threshold 재산출
- 재산출 공식과 클램프 (하한 = 설정값, 상한 =
ceiling_percent × context_length) compression.adaptive.*플러그인 전용 config 네임스페이스- 설정 파리티: host가 외부 엔진에 전달하지 않는
compression.*/model.max_tokens전부를 플러그인이 직접 로드해super().__init__에 전달 (agent_init.py:2492). 누락 시protect_last_n40→20,target_ratio0.10→0.20으로 조용히 회귀한다. - 단위 테스트 (플러그인 디렉터리 내
tests/)
OUT-SCOPE
hermes-agentrepo 내 어떤 파일도 수정하지 않는다.plugins/context_engine/경로도 포함.- 바닥 자체를 줄이는 작업 (
protect_last_n축소, 툴 스키마 다이어트) — 별개 트레이드오프 - 압축 알고리즘·요약 품질·
target_ratio의미 변경 - 상류 PR 제출
- 512K 미만 모델 대응. 해당 구간은 기존
_SMALL_CTX_THRESHOLD_PERCENT플로어가 이미 처리한다.
DEPENDENCY
agent/context_compressor.py—ContextCompressor.__init__(:2513),update_model()(:2295),update_from_response()(:2741),record_completed_compaction()(:2091),_verify_compaction_cleared_threshold플래그agent/context_engine.py:89—ContextEngineABC (name,update_from_response,should_compress,compress)agent/agent_init.py:2430-2560— 엔진 선택 및update_model()호출,copy.deepcopy단계hermes_cli/plugins.py:664, 1526— 등록 API 및 사용자 플러그인 경로config.yaml—context.engine,plugins.enabled,compression.*
RISK
- 내부 클래스 상속 커플링. merge conflict는 사라지지만 상류가
update_from_response시그니처나_verify_compaction_cleared_threshold를 바꾸면 런타임에서 깨진다. 다만 조용한 오작동이 아니라 시끄러운 실패이고, 수정 범위가 내 파일 1개로 국한된다. - deepcopy 실패 시 조용한 폴백.
agent_init.py는 등록된 엔진 싱글턴을 에이전트마다copy.deepcopy한다. 실패하면 경고만 남기고 내장 compressor로 폴백해 플러그인이 무력화된 채 정상 동작처럼 보인다. 등록 시점에 lock·DB 핸들을 보유하지 않아야 한다. - threshold 상승에 따른 provider 400. 트리거 직후 한 턴이 큰 툴 출력을 반환하면 하드 리밋에 근접할 수 있다.
ceiling_percent로 방어한다. - anti-thrash breaker와의 상호작용. 기존
_ineffective_compression_count(:2970)는 압축이 threshold를 못 넘길 때 압축을 차단한다. threshold가 자가 상승하면 이 breaker가 거의 트립하지 않는데, 그것이 의도인 동시에 "정말로 압축 불가능한 상태"를 가리는 부작용이 된다. 상한에 도달한 뒤에는 기존 breaker가 그대로 동작해야 한다. - 바닥 관측 지연. 세션 첫 압축 전에는 바닥 실측치가 없다. 그때까지는 설정 하한을 사용한다.
TIME-ESTIMATED
5hr
- 플러그인 스캐폴드 + 로드 검증: 1hr
- 재산출 로직: 1hr
- 단위 테스트: 1.5hr
- 실세션 실측 검증: 1.5hr
설계
재산출 공식
floor := 압축 직후 provider가 보고한 실제 prompt 토큰
dynamic := ceil(floor × gap_multiplier)
lower := configured_threshold_percent × effective_window
upper := ceiling_percent × context_length
threshold := clamp(max(lower, dynamic), lower, upper)
gap_multiplier기본 2.0 — "바닥이 먹은 만큼의 신규 컨텍스트를 최소한 보장한다"는 의미- 관측할 때마다 재계산한다. 단조 증가시키지 않으므로 바닥이 줄면 threshold도 따라 내려온다.
- 바닥 관측 이력이 없으면
lower를 그대로 쓴다 — 즉 기본 동작이 현행과 동일하다.
현재 실측치 대입: floor 256,857 → dynamic 513,714 → lower 786,432(threshold 0.75)이 더 크므로 786,432 채택. 바닥이 400K까지 커지면 dynamic 800,000이 lower를 넘어 threshold가 따라 올라간다. upper는 891,289.
오버라이드 지점을 threshold_tokens 프로퍼티로 잡지 않는 이유
update_model() (:2335)은 self.threshold_tokens = ...로 대입하고, 이어서 tail_token_budget을 그 값에서 파생시킨다. getter를 순수 계산식으로 오버라이드하면 이 대입들과 충돌한다. 따라서 기존 setter를 그대로 쓰고, update_from_response()에서 super() 반환 후 재대입한다.
바닥 관측 지점
record_completed_compaction() (:2091)이 _verify_compaction_cleared_threshold = True로 arming하고, update_from_response() (:2741)가 소비하면서 False로 되돌린다. 서브클래스는 super() 호출 전에 이 플래그를 읽어야 한다.
def update_from_response(self, usage):
just_compacted = self._verify_compaction_cleared_threshold
super().update_from_response(usage)
if just_compacted and self.last_prompt_tokens > 0:
self._observed_floor = self.last_prompt_tokens
self._retune()
파일 배치
~/.hermes/plugins/adaptive-threshold/
plugin.yaml # name, version, description, author, kind: standalone
__init__.py # AdaptiveThresholdEngine + register(ctx)
tests/
# ~/.hermes/config.yaml
context:
engine: adaptive-threshold
plugins:
enabled:
- adaptive-threshold
compression:
threshold: 0.75 # 동적 산출의 하한
adaptive:
gap_multiplier: 2.0
ceiling_percent: 0.85
모델 및 설정 주입
관여하는 모델은 둘이고, 주입 경로가 서로 다르다.
압축 대상 모델 (main agent model). register(ctx)는 플러그인 discovery 시점에 호출되며 이때 모델은 아직 미정이다. model=""로 생성하고, host가 agent_init.py:2518에서 update_model(model, context_length, base_url, api_key, provider, api_mode)로 실제 값을 주입한다. update_model()은 context_length 대입 → threshold_percent 재해석 → threshold_tokens 재계산 → tail_token_budget 재파생까지 수행하므로, 생성 시점의 빈 모델이 뒤에 남지 않는다. _resolve_context_length()는 lazy 프로퍼티이고 context_length setter가 _resolved_context_length를 직접 채우므로, 빈 모델로 창 크기를 조회하는 일은 발생하지 않는다.
요약용 auxiliary 모델. 플러그인이 설정할 필요가 없다. self.summary_model = summary_model_override or "" (:2665)이고, 빈 값이면 요약 호출 시점에 _resolve_task_provider_model (:4210)이 auxiliary.compression.*에서 해석한다. 현 프로필 기준 openrouter / deepseek/deepseek-v4-flash. 따라서 summary_model_override=None을 유지한다.
설정 파리티 — 외부 엔진에 전달되지 않는 값들
이 스펙의 가장 큰 함정이다. host는 외부 엔진에 compression.*를 거의 전달하지 않는다 (agent_init.py:2492 주석). 내장 경로(agent_init.py:2528)는 ContextCompressor(...)에 15개 인자를 넘기지만, 외부 엔진은 update_model()과 model_thresholds 대입만 받는다. 플러그인이 직접 읽지 않으면 사용자 설정이 조용히 기본값으로 되돌아간다.
__init__ 파라미터 |
config 키 | 외부 엔진 전달 | 미조치 시 값 |
|---|---|---|---|
model |
— | ✅ update_model() |
— |
base_url / api_key / provider / api_mode |
— | ✅ update_model() |
— |
config_context_length |
— | ✅ update_model(context_length=) |
— |
model_thresholds |
compression.model_thresholds |
✅ 직접 대입 (:2516) |
— |
threshold_percent |
compression.threshold |
❌ | 0.50 |
protect_last_n |
compression.protect_last_n |
❌ | 20 (설정 40 유실) |
summary_target_ratio |
compression.target_ratio |
❌ | 0.20 (설정 0.10 유실) |
protect_first_n |
compression.protect_first_n |
❌ | 3 |
abort_on_summary_failure |
compression.abort_on_summary_failure |
❌ | False |
threshold_tokens_cap |
compression.threshold_tokens |
❌ | None |
proactive_prune_tokens 외 2 |
compression.proactive_prune_* |
❌ | 0 / 8000 / 4096 |
min_tail_user_messages |
compression.min_tail_user_messages |
❌ | 1 |
max_tokens |
model.max_tokens |
❌ | None |
quiet_mode |
— | ❌ | False |
_micro_compact_* |
compression.micro_compact* |
✅ hasattr 대입 (:2559) |
— |
max_tokens는 별도 주의가 필요하다. update_model()에서 max_tokens=None은 "미지정 → 기존 값 유지" 의미이고 (:2331), host의 호출은 이 인자를 아예 넘기지 않는다. 따라서 __init__에서 설정한 값이 세션 내내 유지된다. model.max_tokens를 config에서 읽어 넘기면 되지만, caller가 런타임에 직접 max_tokens를 준 경우 (agent_init.py:857)는 플러그인이 알 수 없다. 현 프로필은 model.max_tokens 미설정이므로 None이 정답이고 내장 경로와 일치한다.
설정 로드 방식
번들 플러그인들이 쓰는 패턴을 그대로 따른다 — 함수 내부 지연 import (plugins/image_gen/openrouter/__init__.py:77, plugins/memory/byterover/__init__.py:72).
def register(ctx):
from hermes_cli.config import load_config, cfg_get
cfg = load_config()
ctx.register_context_engine(AdaptiveThresholdEngine(
model="",
threshold_percent=cfg_get(cfg, "compression", "threshold", default=0.50),
protect_last_n=cfg_get(cfg, "compression", "protect_last_n", default=20),
summary_target_ratio=cfg_get(cfg, "compression", "target_ratio", default=0.20),
max_tokens=cfg_get(cfg, "model", "max_tokens", default=None),
# ... 위 표의 ❌ 행 전부
))
load_config()가 활성 프로필을 알아서 해석하므로 프로필별 분기는 불필요하다. 등록 시점 1회 로드이며, 결과는 plain dict이라 copy.deepcopy 제약에 걸리지 않는다.
마스터리스트 (평가지표 체크리스트)
| 항목명 | 설명 | 검증 방법 | ⌛️🏃✅❌ |
|---|---|---|---|
| repo 무결성 | hermes-agent 내 추적 파일이 하나도 변경되지 않음 |
cd ~/.hermes/hermes-agent && git status --porcelain 이 빈 출력 |
⌛️ |
| 업데이트 내성 | hermes update 후에도 플러그인이 그대로 로드됨 |
업데이트 실행 → hermes plugins list에 adaptive-threshold 존재, 충돌 메시지 없음 |
⌛️ |
| 엔진 선택 | 내장 compressor 대신 플러그인 엔진이 활성화됨 | 로그에 Using context engine: adaptive-threshold |
⌛️ |
| deepcopy 안전성 | 에이전트별 copy.deepcopy가 성공 |
로그에 could not be safely copied 경고 부재 + 위 엔진 선택 로그 동시 확인 |
⌛️ |
| config 무시 내성 | compression.adaptive.* 미지 키가 host 로드를 깨지 않음 |
hermes config show 정상 출력, 스키마 경고 없음 |
⌛️ |
| 설정 파리티 | 내장 compressor와 동일한 compression.* 유효값 |
신규 세션에서 엔진 속성 덤프 → protect_last_n == 40, summary_target_ratio == 0.10, max_tokens is None |
⌛️ |
| 요약 모델 해석 | auxiliary 요약이 auxiliary.compression.*로 라우팅됨 |
압축 1회 유발 후 로그에서 요약 호출 provider/model이 openrouter / deepseek-v4-flash인지 확인 |
⌛️ |
| 하한 보장 | 바닥 관측 전에는 설정값과 동일한 threshold | 신규 세션 첫 턴 threshold_tokens == 786,432 |
⌛️ |
| 동적 상승 | 바닥이 커지면 threshold가 따라 상승 | 단위 테스트: floor 400,000 주입 → threshold 800,000 | ⌛️ |
| 상한 클램프 | 상한을 절대 넘지 않음 | 단위 테스트: floor 900,000 주입 → threshold == 891,289 | ⌛️ |
| 하한 클램프 | 하한 아래로 내려가지 않음 | 단위 테스트: floor 10,000 주입 → threshold == 786,432 | ⌛️ |
| 모델 전환 재보정 | /model 전환 후 새 창 기준으로 재산출 |
단위 테스트: update_model(context_length=272_000) 후 상·하한 재계산 확인 |
⌛️ |
| 압축 빈도 개선 | 동일 작업량에서 압축 횟수 감소 | 실세션 비교: 압축 전후 compression_count / 턴 수 비율 |
⌛️ |
| 폴백 안전성 | 플러그인 로드 실패 시 에이전트가 계속 동작 | __init__.py를 의도적으로 깨뜨린 뒤 세션 시작 → 내장 compressor로 정상 진행 |
⌛️ |
Usecase Scenarios
UC001 압축 직후 바닥 관측 및 재산출
[액터] AdaptiveThresholdEngine
[전제조건] 세션이 활성 상태이고, 직전 턴에서 압축 경계가 기록되어 _verify_compaction_cleared_threshold가 True다.
[시나리오]
- provider 응답이 도착해
update_from_response(usage)가 호출된다. - 엔진이 super() 호출 전에
_verify_compaction_cleared_threshold를 읽어just_compacted로 보관한다. super().update_from_response(usage)가last_prompt_tokens를 갱신하고 플래그를 소비한다.just_compacted가 참이면last_prompt_tokens를_observed_floor로 기록한다.- 재산출 공식으로 새 threshold를 계산해
self.threshold_tokens에 대입한다.
[사후조건] threshold_tokens가 [lower, upper] 범위 안에 있고, floor × gap_multiplier 이상이다.
[예외흐름] usage에 prompt_tokens가 없거나 0 이하면 재산출을 건너뛰고 직전 값을 유지한다.
UC002 바닥 관측 이력이 없는 세션
[액터] AdaptiveThresholdEngine
[전제조건] 세션 시작 직후, 압축이 한 번도 발생하지 않았다.
[시나리오]
- 엔진이
_observed_floor부재를 확인한다. lower(= 설정compression.threshold기반)를 그대로 사용한다.
[사후조건] 동작이 내장 compressor와 동일하다.
[예외흐름] 없음.
UC003 상한 도달
[액터] AdaptiveThresholdEngine
[전제조건] 툴 스키마 증가로 바닥이 창의 45%를 초과했다.
[시나리오]
dynamic이upper를 초과한다.- 클램프가
upper로 잘라낸다. - 압축이 threshold를 계속 못 넘기면 기존
_ineffective_compression_countbreaker가 정상적으로 트립한다.
[사후조건] threshold가 ceiling_percent × context_length를 넘지 않고, 압축 불가 상태가 사용자에게 경고로 노출된다.
[예외흐름] breaker가 트립하면 자동 압축이 차단되며, 이는 기존 host 동작이므로 플러그인이 개입하지 않는다.
UC004 모델 전환
[액터] 사용자, AdaptiveThresholdEngine
[전제조건] 1M 창 모델로 세션 진행 중, 바닥이 관측된 상태.
[시나리오]
- 사용자가
/model로 272K 창 모델로 전환한다. - host가
update_model(model, context_length=272_000, ...)를 호출한다. - super()가
threshold_percent·threshold_tokens·tail_token_budget을 새 창 기준으로 재계산한다. - 엔진이
_observed_floor를 무효화하고lower로 되돌린다.
[사후조건] 이전 창에서 관측한 바닥이 새 창의 threshold를 오염시키지 않는다.
[예외흐름] 512K 미만 창이므로 host의 _effective_threshold_percent 플로어(75%)가 적용된다. 플러그인은 이를 덮어쓰지 않고 그 결과를 lower로 채택한다.
UC005 플러그인 로드 실패
[액터] host (agent_init)
[전제조건] context.engine: adaptive-threshold이지만 플러그인 import 또는 deepcopy가 실패한다.
[시나리오]
load_context_engine()과get_plugin_context_engine()이 모두None또는 복사 불가를 반환한다.- host가 경고를 남기고 내장
ContextCompressor를 구성한다.
[사후조건] 세션이 정상 시작되고 압축은 compression.threshold 정적 값으로 동작한다.
[예외흐름] 이 경로는 조용한 성능 저하가 되므로, 마스터리스트의 deepcopy 안전성 항목으로 명시 검증한다.
참고자료
~/.hermes/hermes-agent/agent/context_compressor.py—_compute_threshold_tokens:2473,_effective_threshold_percent:2456,update_from_response:2741,record_completed_compaction:2091,_SMALL_CTX_THRESHOLD_PERCENT:821~/.hermes/hermes-agent/agent/context_engine.py:89—ContextEngineABC~/.hermes/hermes-agent/agent/agent_init.py:2430-2560— 엔진 선택 순서,update_model(), deepcopy~/.hermes/hermes-agent/hermes_cli/plugins.py:664,1526—register_context_engine, 사용자 플러그인 경로~/.hermes/hermes-agent/hermes_cli/update_cmd.py:1098-1332— stash/pull/apply 및 충돌 처리- 실측 세션 export
hermes-b4cea60738fe.json—context_length,threshold_tokens,last_prompt_tokens